"""
ForgeFed plugin for Pagure.
Copyright (C) 2020-2021 zPlus <zplus@peers.community>

This program is free software; you can redistribute it and/or modify
it under the terms of the GNU General Public License as published by
the Free Software Foundation; either version 2 of the License, or
(at your option) any later version.

This program is distributed in the hope that it will be useful,
but WITHOUT ANY WARRANTY; without even the implied warranty of
MERCHANTABILITY or FITNESS FOR A PARTICULAR PURPOSE.  See the
GNU General Public License for more details.

You should have received a copy of the GNU General Public License along
with this program; if not, see <https://www.gnu.org/licenses/>.

SPDX-FileCopyrightText:  2020-2021 zPlus <zplus@peers.community>
SPDX-License-Identifier: GPL-2.0-only
"""

import copy
import datetime
import logging
import random
import re
import requests
import string

from . import APP_URL
from . import model
from . import settings

log = logging.getLogger(__name__)

# List of HTTP headers used by ActivityPub.
# https://www.w3.org/TR/activitypub/#server-to-server-interactions
default_header  = 'application/ld+json; profile="https://www.w3.org/ns/activitystreams"'
optional_header = 'application/activity+json'
headers         = [ default_header, optional_header ]

# Headers to use when GETting/POSTing remote objects
REQUEST_HEADERS = { 'Accept':       default_header,
                    'Content-Type': default_header}

# The JSON-LD context to use with ActivityPub activities.
jsonld_context = [
    'https://www.w3.org/ns/activitystreams',
    'https://w3id.org/security/v1',
    'https://forgefed.peers.community/ns' ]

# Fetch an ActivityPub object
def fetch(uri):
    response = requests.get(uri, headers=REQUEST_HEADERS)

    if response.status_code != 200:
        log.info('[{}] Error while retrieving object: {}'.format(response.status_code, uri))
        return None

    # The remote server is expected to serve a JSON-LD document.
    object = response.json()

    # Because JSON-LD can represent the same graph in several different
    # ways, we should normalize the JSONLD object before passing it to the
    # actor for processing. This simplifies working with the object.
    # Normalization could mean "flattening" or "compaction" of the JSONLD
    # document.
    # However, this step is left out for now and not implemented unless
    # needed because the ActivityStream specs already specifies that
    # objects should be served in compact form:
    # https://www.w3.org/TR/social-web-protocols/#content-representation
    #
    # object = response.json()
    # return normalize(object)

    return Document(object)

# Cache a copy of the JSON-LD context such that we can work with activities
# without sending HTTP requests all the time.
cached_jsonld_context = []
for context in jsonld_context:
    cached_jsonld_context.append(requests.get(context, headers=REQUEST_HEADERS).json())

def format_datetime(dt):
    """
    This function is used to format a datetime object into a string that is
    suitable for publishing in Activities.
    """

    return dt.replace(microsecond=0) \
             .replace(tzinfo=datetime.timezone.utc) \
             .isoformat()

def new_activity_id(length=32):
    """
    Generate a random string suitable for using as part of an Activity ID.
    """

    symbols = string.ascii_lowercase + string.digits
    return ''.join([ random.choice(symbols) for i in range(length) ])

class Document(dict):
    """
    A Document represents a JSON-LD "document". It's an extension of a Python
    dictionary with a couple of extra methods that facilitate the handling of
    ActivityPub objects.
    """

    # This is a type that can be used when matching a Document against another
    # Document (see match()). It's a special type that doesn't match any value;
    # it merely checks that the given key exists.
    # Usage example: pattern={ 'key': Document.AnyType() }
    class AnyType: pass

    def match(self, pattern):
        """
        Check if this Document matches another dictionary. If this Document
        contains all the keys in :pattern:, and they have the same value, then
        the function returns True. Otherwise return False.
        To only check for existence of a key, not its value, use the empty
        dictionary like this: pattern={"key": {}}

        :param pattern: The Document (or Python dictionary) to match this Document against.
        """

        for key, value in pattern.items():
            if key not in self:
                return False

            if isinstance(value, Document.AnyType):
                continue

            if isinstance(value, dict):
                self.node(key)

                if not self[key].match(value):
                    return False

            elif isinstance(value, re.Pattern):
                if not isinstance(self[key], str):
                    return False

                # Search if the given regex matches the value in the dictionary
                if value.search(self[key]) is None:
                    return False

            else:
                if self[key] != value:
                    return False

        return True

    def first(self, property):
        """
        Return value if it's a scalar, otherwise return the first element if it
        is an array.
        """

        if isinstance(self[property], list):
            return self[property][0]

        if isinstance(self[property], dict):
            return None

        return self[property]

    def node(self, property):
        """
        Return the value if it's an object. If it's a string, try to fetch a
        URI.
        """

        if isinstance(self[property], Document):
            return self[property]

        if isinstance(self[property], dict):
            self[property] = Document(self[property])
            return self[property]

        if isinstance(self[property], str):
            self[property] = Document(fetch(self[property]))
            return self[property]

        return None

class Activity(Document):
    def from_dict(activity):
        """
        Return an Activity document given an Activity dictionary
        """

        document = Activity(type=activity['type'])
        document.update(**activity)
        return document

    def __init__(self, type='Activity', *args, **kwargs):
        super().__init__(*args, **kwargs)

        self.update(kwargs)

        self.update({
            '@context': jsonld_context,
            'type': type
        })

    def get_receivers_addresses(self):
        """
        Helper function for extracting a list of all the recipients of an Activity.
        """

        recipients = []

        for field in [ 'to', 'cc', 'bto', 'bcc' ]:
            if field not in self:
                continue

            if isinstance(self[field], str):
                recipients.append(self[field])
            elif isinstance(self[field], list):
                recipients.extend(self[field])
            else:
                log.error('Field {} in Activity {} cannot be of type {}'.format(field, self['id'], type(self[field])))

        # Remove duplicates in the list of recipients, so that we will schedule
        # only one task for the same recipient Actor.
        # NOTE list(set()) might change the order of the recipients because set()
        #      does not preserve the order. However this is not a problem because
        #      for each recipient a separate task is scheduled.
        recipients = list(set(recipients))

        return recipients

    def get_forwarding_addresses(self):
        """
        Return only a list of addresses for INBOX forwarding.
        """

        recipients = self.get_receivers_addresses()

        return [
            recipient for recipient in recipients
                      if  recipient.startswith(APP_URL)
        ]

    def distribute(self):
        # Defer importing "tasks" to only when it's needed, here. Otherwise it
        # creates a circular dependency error during initialization.
        # "tasks" needs to import other modules that in turn need to import the
        # "activitupub" module.
        from . import tasks

        tasks.delivery.distribute.delay(self)

class Collection(Document):
    def __init__(self):
        self.update({
            '@context': jsonld_context,
            'type': 'Collection'
        })

class CryptographicKey(Document):
    def __init__(self, key):
        self.update({
            '@context': jsonld_context,
            'type':     'CryptographicKey',
            'id':       key.uri,
            'owner':    key.actor_uri,
            'publicKeyPem': key.publickey_pem,
            'created':  None,
            'expires':  None,
            'revoked':  None,
            #'privateKeyPem': DO NOT DISPLAY PRIVATE KEY
        })

class SshKey(Document):
    def __init__(self, key):
        # Break up the SSH key components
        # An OpenSSH is formatted like <algorithm> <key> <comment>
        components = key.public_ssh_key.split(' ', 2)
        algorithm  = components[0].strip()
        string     = components[1].strip()
        comment    = components[2].strip() if len(components) > 2 else ''

        self.update({
            '@context':     jsonld_context,
            'type':         'SshKey',
            'id':           key.uri,
            'owner':        key.user.uri,
            'sshKeyType':   algorithm,
            'content':      key.public_ssh_key,
            'created':      None,
            'expires':      None,
            'revoked':      None,
        })

class OrderedCollection(Document):
    def __init__(self, collection_uri):
        self.update({
            '@context': jsonld_context,
            'type': 'OrderedCollection',
            'id': collection_uri,
            'current': collection_uri + '/0',
            'first':  collection_uri + '/0',
            'last': collection_uri + '/0',
            'totalItems': 0
        })

class OrderedCollectionPage(Document):
    def __init__(self, collection_uri, page_uri, items):
        self.update({
            '@context': jsonld_context,
            'type':     'OrderedCollectionPage',
            'id':       page_uri,
            'partOf':   collection_uri,
            'orderedItems': items
        })

class Person(Document):
    def __init__(self, actor):
        self.update({
            '@context': jsonld_context,
            'type': 'Person',
            'id': actor.uri,
            'inbox': actor.uri + '/inbox',
            'outbox': actor.uri + '/outbox',
            'followers': actor.uri + '/followers',
            'following': actor.uri + '/following',
            'publicKey': actor.uri + '/key.pub',
            'name': actor.fullname,
            'preferredUsername': actor.username,
            'sshKey': [ key.uri for key in actor.sshkeys ],
            'roles': [ role.uri for role in actor.roles ]
        })

class Group(Document):
    def __init__(self, group):
        self.update({
            '@context': jsonld_context,
            'type': 'Group',
            'id': group.uri,
            'name': group.display_name,
            'preferredUsername': group.group_name,
            'summary': group.description,
            'roles': [ role.uri for role in group.roles ]
        })

class Role(Document):
    def __init__(self, project, role):
        self.update({
            '@context': jsonld_context,
            'type':     'Role',
            'id':       '{}/role/{}'.format(project.uri, role.access),
            'name':     role.access,
            'context':  project.uri
        })

class Project(Document):
    def __init__(self, project):
        self.update({
            '@context': jsonld_context,
            'type': 'Project',
            'id': project.uri,
            'name': project.fullname,
            'repository': [ project.uri + '.git' ],
            'tickettracker': [ project.uri + '/issues' ]
        })

class Tag(Document):
    def __init__(self, tag):
        self.update({
            '@context': jsonld_context,
            'type': 'Tag',
            'id': tag.uri,
            'name': tag.tag,
            'summary': tag.tag_description
        })

class Repository(Document):
    def __init__(self, project, repository, git_repository):
        self.update({
            '@context':  jsonld_context,
            'type':      'Repository',
            'id':        repository.uri,
            'inbox':     repository.uri + '/inbox',
            'outbox':    repository.uri + '/outbox',
            'followers': repository.uri + '/followers',
            'following': repository.uri + '/following',
            'publicKey': repository.uri + '/key.pub',
            'name':      repository.fullname,
            'preferredUsername': repository.fullname,
            'project':   project.uri,
            'refs':      repository.uri + '/refs'
        })

class Branch(Document):
    def __init__(self, project, repository, branch_name):
        self.update({
            '@context': jsonld_context,
            'type': 'Branch',
            'id': '{}/tree/{}'.format(project.uri, branch_name),
            'context': repository.uri
        })

class Commit(Document):
    def __init__(self, project, repository, commit):
        """
        :param commit: The commit
        :type commit: pygit2.Commit
        """

        self.update({
            '@context': jsonld_context,
            'type': 'Commit',
            'id': '{}/c/{}'.format(project.uri, commit.id.hex),
            'context': repository.uri,
            'attributedTo': '',
            'committedBy': '',
            'hash': commit.id.hex,
            'summary': commit.message.split('\n', 1)[0],
            'description': commit.message,
            'created': '',
            'committed': commit.commit_time
        })

class Refs(Document):
    def __init__(self, repository, git_repository):
        # Get all the tags
        regex = re.compile('^refs/tags/')
        tags = [ ref
                 for ref in git_repository.references
                 if regex.match(ref) ]

        self.update({
            '@context': jsonld_context,
            'type': 'Refs',
            'id': '{}/refs'.format(repository.uri),
            'context': repository.uri,
            'heads': [],
            'remotes': [],
            'tags': [ '{}/{}'.format(repository.uri, ref) for ref in tags ]
        })

class TagRef(Document):
    def __init__(self, repository, ref):
        self.update({
            '@context': jsonld_context,
            'type': 'TagRef',
            'id': '{}/{}'.format(repository.uri, ref),
            'name': ref,
            'context': repository.uri
        })

class TicketTracker(Document):
    def __init__(self, tracker, project):
        self.update({
            '@context': jsonld_context,
            'type': 'TicketTracker',
            'id':        tracker.uri,
            'inbox':     tracker.uri + '/inbox',
            'outbox':    tracker.uri + '/outbox',
            'followers': tracker.uri + '/followers',
            'following': tracker.uri + '/following',
            'publicKey': tracker.uri + '/key.pub',
            'name':      tracker.fullname,
            'preferredUsername': tracker.fullname,
            'project':   project.uri
        })

class Ticket(Document):
    def __init__(self, ticket, tickettracker):
        self.update({
            '@context': jsonld_context,
            'type': 'Ticket',
            'id': ticket.uri,
            'context': tickettracker.uri,
            'attributedTo': ticket.user.uri,
            'summary': ticket.title,
            'content': ticket.content,
            'mediaType': "text/plain",
            'source': {
                'content': ticket.content,
                'mediaType': 'text/markdown; variant=CommonMark'
            },
            'assignedTo': [ ticket.assignee.uri ] if ticket.assignee else [],
            'isResolved': ticket.status.upper() == 'CLOSED',
            'depends': [ parent.uri for parent in ticket.parents ],
            'tags': sorted([ tag.uri for tag in ticket.tags ]),
            'milestones': [ ticket.milestone ] if ticket.milestone else []
        })

class TicketComment(Document):
    def __init__(self, comment):
        self.update({
            '@context': jsonld_context,
            'id': comment.uri,
            'type': 'Note',
            'context': comment.issue.uri,
            'attributedTo': comment.user.uri,
            'inReplyTo': None, # Pagure does not use nested comments
            'mediaType': 'text/plain',
            'content': comment.comment,
            'source': {
                'mediaType': 'text/markdown; variant=Commonmark',
                'content': comment.comment
            },
            'published': format_datetime(comment.date_created)
        })

class MergeRequest(Document):
    def __init__(self, mergerequest):
        """
        :param mergerequest: The Pagure PullRequest object
        """

        # PullRequest in the Pagure model has a foreign-key link to Project class.
        # We need to "cast" Project to Repository in order to get the correct
        # federation URI.
        # Additionally a PullRequest in Pagure might not have a "project_from"
        # link, but it should have a "remote_git" if it's a "remote PR".

        #upstream   = copy.deepcopy(mergerequest.project)
        upstream   = mergerequest.project
        upstream.__class__ = model.Repository
        upstream = upstream.uri

        #downstream = copy.deepcopy(mergerequest.project_from)
        downstream = mergerequest.project_from
        if downstream:
            downstream.__class__ = model.Repository
            downstream = downstream.uri
        elif mergerequest.remote_git:
            downstream = mergerequest.remote_git

        self.update({
            '@context': jsonld_context,
            'type': 'MergeRequest',
            'id': mergerequest.uri,
            'attributedTo': mergerequest.user.uri,
            'summary': mergerequest.title,
            'content': mergerequest.initial_comment,
            'downstream': {
                'repository': downstream,
                'branch': mergerequest.branch_from
            },
            'upstream': {
                'repository': upstream,
                'branch': mergerequest.branch
            },
            'commit_start': mergerequest.commit_start,
            'commit_stop': mergerequest.commit_stop
        })

class MergeRequestComment(Document):
    def __init__(self, comment):
        self.update({
            '@context': jsonld_context,
            'id': comment.uri,
            'type': 'Note',
            'context': comment.pull_request.uri,
            'attributedTo': comment.user.uri,
            'inReplyTo': None, # Pagure does not use nested comments
            'mediaType': 'text/plain',
            'content': comment.comment,
            'source': {
                'mediaType': 'text/markdown; variant=Commonmark',
                'content': comment.comment
            },
            'published': format_datetime(comment.date_created)
        })
